package ru.lukyanets.torrent;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import ru.lukyanets.torrent.bencode.BDecoder;
import ru.lukyanets.torrent.bencode.BEncoder;

import java.io.*;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;

/**
 * @author slukyanets
 */
public class TorrentInfoMapper {

    static final String FIELD_ANNOUNCE = "announce";
    static final String FIELD_COMMENT = "comment";
    static final String FIELD_CREATED_BY = "created by";
    static final String FIELD_CREATION_DATE = "creation date";
    static final String FIELD_ENCODING = "encoding";
    static final String FIELD_INFO = "info";
    static final String FIELD_NAME = "name";
    static final String FIELD_PIECE_LENGTH = "piece length";
    static final String FIELD_PIECES = "pieces";
    static final String FIELD_FILES = "files";
    static final String FIELD_LENGTH = "length";
    static final String FIELD_PATH = "path";

    static final String VALUE_EMPTY_NAME = "noDirSpec";

    /**
     *  Map BEncoded bytes to torrent info {@link TorrentInfo}
     * @param file BEncoded torrent file
     * @return torrent information bean
     * @throws IOException
     */
    public static TorrentInfo from(File file) throws IOException {
        return from(new FileInputStream(file));
    }

    /**
     *  Map BEncoded bytes to torrent info {@link TorrentInfo}
     * @param is BEncoded data
     * @return torrent information bean
     * @throws IOException - throws exception
     */
    public static TorrentInfo from(InputStream is) throws IOException {
        Map infoMap = (Map) BDecoder.decode(new BufferedInputStream(is));
        if (infoMap == null || infoMap.get(FIELD_ANNOUNCE) == null) {
            throw new IOException("Invalid torrent data");
        }

        TorrentInfo torrentInfo = new TorrentInfo();
        torrentInfo.setAnnounceURL(getStringValue(infoMap.get(FIELD_ANNOUNCE)));
        torrentInfo.setComment(getStringValue(infoMap.get(FIELD_COMMENT)));
        torrentInfo.setCreatedBy(getStringValue(infoMap.get(FIELD_CREATED_BY)));
        torrentInfo.setCreationDate(getDateValue(infoMap.get(FIELD_CREATION_DATE)));
        torrentInfo.setEncoding(getStringValue(infoMap.get(FIELD_ENCODING)));

        Map info = (Map) infoMap.get(FIELD_INFO);
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        BEncoder.encode(info, os);
        torrentInfo.setInfoHash(hash(os.toByteArray()));
        torrentInfo.setSaveAs(getStringValue(info.get(FIELD_NAME)));
        torrentInfo.setPieceLength(((Long) info.get(FIELD_PIECE_LENGTH)).intValue());

        byte[] piecesHash = (byte[]) info.get(FIELD_PIECES);
        if (piecesHash == null || piecesHash.length % 20 != 0) {
            throw new IOException("Invalid torrent pieces data");
        }
        for (int i = 0; i < piecesHash.length / 20; i++) {
            torrentInfo.getPieceHash().add(ArrayUtils.subarray(piecesHash, i * 20, i * 20 + 20));
        }

        List files = (List) info.get(FIELD_FILES);
        if (files != null) {
            torrentInfo.setTotalLength(0);
            for (Object fileObject : files) {
                Map fileInfo = (Map) fileObject;
                long fileLength = (Long) fileInfo.get(FIELD_LENGTH);
                torrentInfo.getLength().add(fileLength);
                torrentInfo.setTotalLength(torrentInfo.getTotalLength() + fileLength);

                StringBuilder pathBuilder = new StringBuilder();
                Iterator pathIterator = ((List) fileInfo.get(FIELD_PATH)).iterator();
                while(pathIterator.hasNext()) {
                    pathBuilder.append(getStringValue(pathIterator.next()));
                    if (pathIterator.hasNext()) {
                        pathBuilder.append(File.pathSeparator);
                    }
                }
                torrentInfo.getName().add(pathBuilder.toString());
            }
        } else {
            Long length = (Long) info.get(FIELD_LENGTH);
            torrentInfo.getLength().add(length);
            torrentInfo.setTotalLength(length);
            torrentInfo.getName().add(getStringValue(info.get(FIELD_NAME)));
        }

        return torrentInfo;
    }

    /**
     * Map torrentInfo to BEncoded byte array
     * @param torrentInfo torrent information {@link TorrentInfo}
     * @param os output stream for encoded data
     * @throws IOException
     */
    public static void to(TorrentInfo torrentInfo, OutputStream os) throws IOException {
        Map<Object, Object> torrentInfoMap = new LinkedHashMap<>();
        torrentInfoMap.put(FIELD_ANNOUNCE, torrentInfo.getAnnounceURL());
        if (StringUtils.trimToNull(torrentInfo.getComment()) != null) {
            torrentInfoMap.put(FIELD_COMMENT, torrentInfo.getComment());
        }
        if (StringUtils.trimToNull(torrentInfo.getCreatedBy()) != null) {
            torrentInfoMap.put(FIELD_CREATED_BY, torrentInfo.getCreatedBy());
        }
        if (torrentInfo.getCreatedBy() != null) {
            torrentInfoMap.put(FIELD_CREATION_DATE, torrentInfo.getCreationDate().toEpochSecond());
        }
        if (StringUtils.trimToNull(torrentInfo.getEncoding()) != null) {
            torrentInfoMap.put(FIELD_ENCODING, torrentInfo.getEncoding());
        }

        Map<Object, Object> info = new LinkedHashMap<>();

        if (StringUtils.trimToNull(torrentInfo.getSaveAs()) != null) {
            info.put(FIELD_NAME, torrentInfo.getSaveAs());
        } else {
            info.put(FIELD_NAME, VALUE_EMPTY_NAME);
        }
        info.put(FIELD_LENGTH, torrentInfo.getTotalLength());

        if (torrentInfo.getName().size() > 1) {
            Iterator<String> nameIterator = torrentInfo.getName().iterator();
            Iterator<Long> lengthIterator = torrentInfo.getLength().iterator();
            List<Map<Object, Object>> files = new LinkedList<>();
            while (nameIterator.hasNext() && lengthIterator.hasNext()) {
                Map<Object, Object> file = new LinkedHashMap<>();
                file.put(FIELD_LENGTH, lengthIterator.next());
                List<String> pathList = Arrays.asList(nameIterator.next().split(File.pathSeparator));
                file.put(FIELD_PATH, pathList);
                files.add(file);
            }
            info.put(FIELD_FILES, files);
        }

        info.put(FIELD_PIECE_LENGTH, torrentInfo.getPieceLength());
        byte[] pieces = new byte[torrentInfo.getPieceHash().size() * 20];
        int i = 0;
        for (byte[] piece : torrentInfo.getPieceHash()) {
            System.arraycopy(piece, 0, pieces, (i++) * 20, piece.length);
        }
        info.put(FIELD_PIECES, pieces);

        torrentInfoMap.put(FIELD_INFO, info);

        BEncoder.encode(torrentInfoMap, os);
    }

    private static String getStringValue(Object object) {
        return object != null ? new String((byte[]) object) : null;
    }

    private static ZonedDateTime getDateValue(Object object) {
        return ZonedDateTime.ofInstant(Instant.ofEpochSecond((Long) object), ZoneOffset.UTC);
    }

    /**
     * Compute the SHA-1 hash of the given byte array
     *
     * @param hashThis byte[]
     * @return byte[]
     */
    private static byte[] hash(byte[] hashThis) throws IOException {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            return md.digest(hashThis);
        } catch (NoSuchAlgorithmException e) {
            throw new IOException(e);
        }
    }

}
